<!doctype html>
<html lang="en">
<head>
    <title>Watermill Consumer Groups Example</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD" crossorigin="anonymous">
    <link href="https://unpkg.com/@highlightjs/cdn-assets@11.7.0/styles/default.min.css" rel="stylesheet">
    <style>
    </style>
</head>

<body>

<nav class="navbar bg-body-tertiary mb-5">
    <div class="container-fluid">
        <span class="navbar-brand mb-0 h1">Watermill Consumer Groups Example</span>
    </div>
</nav>

<div id="root"></div>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js" integrity="sha384-w76AqPfDkMBDXo30jS1Sgez6pr3x5MlQ1ZAGC+nuZB+EYdgRZgiwxhTBTkF7CXvN" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://unpkg.com/@highlightjs/cdn-assets@11.7.0/highlight.min.js"></script>

<script type="text/babel">
    function App() {
        const [chapters, setChapters] = React.useState([
            {
                number: 0,
                title: "Consumer Groups",
                description: `
                <p>Consumer groups let you decide which handler receives a message.</p>
                <p>They are related to subscribers.</p>
                <p>Some Pub/Sub take a different approach, but the idea stays the same.</p>
                <p>Follow the tabs above to see all examples in order.</p>
                `,
                services: [],
                code: [],
            },
            {
                number: 1,
                title: "Signing up for the newsletter",
                description: `
                    <p>When a user signs up, a <span class="badge bg-dark">UserSignedUp</span> event is published.</p>

                    <p>In the simplest scenario, there's a single service with one handler subscribed to this event.
                    It's adds the user to the newsletter list.
                    </p>

                    <p>Flip the switch below to start sending messages and see what happens.</p>
                `,
                topic: "UserSignedUp-1",
                services: [
                    {
                        name: "newsletter-service-1",
                        handlers: [
                            {
                                name: "OnUserSignedUp",
                                group: null,
                                received_message_id: "",
                                received_message_at: 0,
                            }
                        ]
                    }
                ],
                code: [{
                    service: 'newsletter-service',
                    code: `router.AddNoPublisherHandler(
    "OnUserSignedUp",
    "UserSignedUp",
    subscriber,
    func(msg *message.Message) error {
        var event common.UserSignedUp
        err := json.Unmarshal(msg.Payload, &event)
        if err != nil {
            return err
        }

        if !event.Consents.Marketing {
            return nil
        }

        fmt.Println("Adding user", event.UserID, "to the promotions list")

        return nil
    },
)`
                }],
            },
            {
                number: 2,
                title: "Introducing the CRM service",
                description: `
                <p>We add one more service. It adds the signed up users to the CRM software.</p>
                <p>Handlers in both services receive the same message.</p>
                `,
                topic: "UserSignedUp-2",
                services: [
                    {
                        name: "newsletter-service-1",
                        handlers: [
                            {
                                name: "OnUserSignedUp",
                                group: null,
                                received_message_id: "",
                                received_message_at: 0,
                            }
                        ]
                    },
                    {
                        name: "crm-service-1",
                        handlers: [
                            {
                                name: "OnUserSignedUp",
                                group: null,
                                received_message_id: "",
                                received_message_at: 0,
                            }
                        ]
                    }
                ],
                code: [{
                    service: 'newsletter-service',
                    code: `router.AddNoPublisherHandler(
    "OnUserSignedUp",
    "UserSignedUp",
    subscriber,
    func(msg *message.Message) error {
        var event common.UserSignedUp
        err := json.Unmarshal(msg.Payload, &event)
        if err != nil {
            return err
        }

        if !event.Consents.Marketing {
            return nil
        }

        fmt.Println("Adding user", event.UserID, "to the promotions list")

        return nil
    },
)`,
                }, {
                    service: 'crm-service',
                    code: `router.AddNoPublisherHandler(
    "OnUserSignedUp",
    "UserSignedUp",
    subscriber,
    func(msg *message.Message) error {
        var event common.UserSignedUp
        err := json.Unmarshal(msg.Payload, &event)
        if err != nil {
            return err
        }

        fmt.Println("Adding user", event.UserID, "to the CRM")

        return nil
    },
)`,
                }],
            },
            {
                number: 3,
                topic: "UserSignedUp-3",
                title: "Two replicas of the newsletter service",
                description: `
<p>We scale the newsletter service horizontally, adding an identical replica.</p>

<p>This can be done for performance or availability reasons.</p>

<div class="alert alert-warning" role="alert">
    <strong>Anti-pattern:</strong> each message is processed twice.
</div>
`,
                services: [
                    {
                        name: "newsletter-service-1",
                        handlers: [
                            {
                                name: "OnUserSignedUp",
                                group: null,
                                received_message_id: "",
                                received_message_at: 0,
                            }
                        ]
                    },
                    {
                        name: "newsletter-service-2",
                        handlers: [
                            {
                                name: "OnUserSignedUp",
                                group: null,
                                received_message_id: "",
                                received_message_at: 0,
                            }
                        ]
                    },
                ],
                code: [{
                    service: 'newsletter-service',
                    code: `router.AddNoPublisherHandler(
    "OnUserSignedUp",
    "UserSignedUp",
    subscriber,
    func(msg *message.Message) error {
        var event common.UserSignedUp
        err := json.Unmarshal(msg.Payload, &event)
        if err != nil {
            return err
        }

        if !event.Consents.Marketing {
            return nil
        }

        fmt.Println("Adding user", event.UserID, "to the promotions list")

        return nil
    },
)`
                }],
            },
            {
                number: 4,
                title: "Introducing a consumer group",
                description: `
                <p>Introducing a consumer group makes messages delivered in a round-robin fashion.</p>
                <p>Each message is processed only once. It is delivered to the group, and the Pub/Sub decides which subscriber receives it.</p>

                <p>The consumer group is a string.</p>
                <p>Some Pub/Subs use different primitives to achieve the same thing (e.g., AMQP).</p>
`,
                topic: "UserSignedUp-4",
                services: [
                    {
                        name: "newsletter-service-1",
                        handlers: [
                            {
                                name: "OnUserSignedUp",
                                group: {
                                    name: "newsletter-service",
                                    color: "primary",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            }
                        ]
                    },
                    {
                        name: "newsletter-service-2",
                        handlers: [
                            {
                                name: "OnUserSignedUp",
                                group: {
                                    name: "newsletter-service",
                                    color: "primary",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            }
                        ]
                    },
                ],
                code: [{
                    service: 'newsletter-service',
                    code: `newsletterServiceGroupSubscriber, err := redisstream.NewSubscriber(
    redisstream.SubscriberConfig{
        Client:        subClient,
        ConsumerGroup: "newsletter-service",
    },
    logger,
)

router.AddNoPublisherHandler(
    "OnUserSignedUp",
    "UserSignedUp",
    newsletterServiceGroupSubscriber,
    func(msg *message.Message) error {
        var event common.UserSignedUp
        err := json.Unmarshal(msg.Payload, &event)
        if err != nil {
            return err
        }

        if !event.Consents.Marketing {
            return nil
        }

        fmt.Println("Adding user", event.UserID, "to the promotions list")

        return nil
    },
)`
                }],
            },
            {
                number: 5,
                title: "Adding a second handler to the newsletter service",
                description: `
<p>We now have two newsletter lists: one for promotions, and one for news.</p>

<p>We add another handler subscribed to the same event.</p>

<p>Note: we changed the first handler's name.</p>

<p>We keep the same consumer groups.</p>

<div class="alert alert-warning" role="alert">
    <strong>Anti-pattern:</strong> every other message is delivered to a different handler.
</div>
`,
                topic: "UserSignedUp-5",
                services: [
                    {
                        name: "newsletter-service-1",
                        handlers: [
                            {
                                name: "AddToPromotionsList",
                                group: {
                                    name: "newsletter-service",
                                    color: "primary",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            },
                            {
                                name: "AddToNewsList",
                                group: {
                                    name: "newsletter-service",
                                    color: "primary",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            }
                        ]
                    },
                ],
                code: [{
                    service: 'newsletter-service',
                    code: `newsletterServiceGroupSubscriber, err := redisstream.NewSubscriber(
    redisstream.SubscriberConfig{
        Client:        subClient,
        ConsumerGroup: "newsletter-service",
    },
    logger,
)

router.AddNoPublisherHandler(
    "AddToPromotionsList",
    "UserSignedUp",
    newsletterServiceGroupSubscriber,
    func(msg *message.Message) error {
        var event common.UserSignedUp
        err := json.Unmarshal(msg.Payload, &event)
        if err != nil {
            return err
        }

        if !event.Consents.Marketing {
            return nil
        }

        fmt.Println("Adding user", event.UserID, "to the promotions list")

        return nil
    },
)

router.AddNoPublisherHandler(
    "AddToNewsList",
    "UserSignedUp",
    newsletterServiceGroupSubscriber,
    func(msg *message.Message) error {
        var event common.UserSignedUp
        err := json.Unmarshal(msg.Payload, &event)
        if err != nil {
            return err
        }

        if !event.Consents.News {
            return nil
        }

        fmt.Println("Adding user", event.UserID, "to the news list")

        return nil
    },
)`
                }],
            },
            {
                number: 6,
                title: "Changing the consumer group to the handler name",
                description: `
                <p>Introducing a consumer group per handler makes messages delivered to both handlers.</p>
`,
                topic: "UserSignedUp-6",
                services: [
                    {
                        name: "newsletter-service-1",
                        handlers: [
                            {
                                name: "AddToPromotionsList",
                                group: {
                                    name: "AddToPromotionsList",
                                    color: "primary",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            },
                            {
                                name: "AddToNewsList",
                                group: {
                                    name: "AddToNewsList",
                                    color: "warning",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            }
                        ]
                    },
                ],
                code: [{
                    service: 'newsletter-service',
                    code: `addToPromotionsListGroupSubscriber, err := redisstream.NewSubscriber(
    redisstream.SubscriberConfig{
        Client:        subClient,
        ConsumerGroup: "AddToPromotionsList",
    },
    logger,
)

addToNewsListGroupSubscriber, err := redisstream.NewSubscriber(
    redisstream.SubscriberConfig{
        Client:        subClient,
        ConsumerGroup: "AddToNewsList",
    },
    logger,
)

router.AddNoPublisherHandler(
    "AddToPromotionsList",
    "UserSignedUp",
    addToPromotionsListGroupSubscriber,
    func(msg *message.Message) error {
        var event common.UserSignedUp
        err := json.Unmarshal(msg.Payload, &event)
        if err != nil {
            return err
        }

        if !event.Consents.Marketing {
            return nil
        }

        fmt.Println("Adding user", event.UserID, "to the promotions list")

        return nil
    },
)

router.AddNoPublisherHandler(
    "AddToNewsList",
    "UserSignedUp",
    addToNewsListGroupSubscriber,
    func(msg *message.Message) error {
        var event common.UserSignedUp
        err := json.Unmarshal(msg.Payload, &event)
        if err != nil {
            return err
        }

        if !event.Consents.News {
            return nil
        }

        fmt.Println("Adding user", event.UserID, "to the news list")

        return nil
    },
)`
                }],
            },
            {
                number: 7,
                title: "Two replicas with two handlers",
                description: `
                <p>We keep one consumer group per handler.</p>
                <p>Events are delivered to each handler in a round-robin fashion.</p>
`,
                topic: "UserSignedUp-7",
                services: [
                    {
                        name: "newsletter-service-1",
                        handlers: [
                            {
                                name: "AddToPromotionsList",
                                group: {
                                    name: "AddToPromotionsList",
                                    color: "primary",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            },
                            {
                                name: "AddToNewsList",
                                group: {
                                    name: "AddToNewsList",
                                    color: "warning",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            }
                        ]
                    },
                    {
                        name: "newsletter-service-2",
                        handlers: [
                            {
                                name: "AddToPromotionsList",
                                group: {
                                    name: "AddToPromotionsList",
                                    color: "primary",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            },
                            {
                                name: "AddToNewsList",
                                group: {
                                    name: "AddToNewsList",
                                    color: "warning",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            }
                        ]
                    },
                ],
                code: [{
                    service: 'newsletter-service',
                    code: `addToPromotionsListGroupSubscriber, err := redisstream.NewSubscriber(
    redisstream.SubscriberConfig{
        Client:        subClient,
        ConsumerGroup: "AddToPromotionsList",
    },
    logger,
)

addToNewsListGroupSubscriber, err := redisstream.NewSubscriber(
    redisstream.SubscriberConfig{
        Client:        subClient,
        ConsumerGroup: "AddToNewsList",
    },
    logger,
)

router.AddNoPublisherHandler(
    "AddToPromotionsList",
    "UserSignedUp",
    addToPromotionsListGroupSubscriber,
    func(msg *message.Message) error {
        var event common.UserSignedUp
        err := json.Unmarshal(msg.Payload, &event)
        if err != nil {
            return err
        }

        if !event.Consents.Marketing {
            return nil
        }

        fmt.Println("Adding user", event.UserID, "to the promotions list")

        return nil
    },
)

router.AddNoPublisherHandler(
    "AddToNewsList",
    "UserSignedUp",
    addToNewsListGroupSubscriber,
    func(msg *message.Message) error {
        var event common.UserSignedUp
        err := json.Unmarshal(msg.Payload, &event)
        if err != nil {
            return err
        }

        if !event.Consents.News {
            return nil
        }

        fmt.Println("Adding user", event.UserID, "to the news list")

        return nil
    },
)`
                }],
            },
            {
                number: 8,
                title: "Adding a second handler to the CRM service",
                description: `
                <p>The CRM service now has one more responsibility: adding users to the Support software.</p>

                <p>Handler names as consumer groups still make sense. However, a good practice is to include the service name as well.</p>

                <p>Thanks to this, there's no risk of conflicts of the handler names between services.</p>

                <p>Note: the replica number is not part of the service name!</p>

                <p>This example features Watermill's CQRS component for easier creation of subscribers.</p>
`,
                topic: "UserSignedUp-8",
                services: [
                    {
                        name: "newsletter-service-1",
                        handlers: [
                            {
                                name: "AddToPromotionsList",
                                group: {
                                    name: "newsletter-service_AddToPromotionsList",
                                    color: "primary",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            },
                            {
                                name: "AddToNewsList",
                                group: {
                                    name: "newsletter-service_AddToNewsList",
                                    color: "warning",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            }
                        ]
                    },
                    {
                        name: "crm-service-1",
                        handlers: [
                            {
                                name: "AddToCRM",
                                group: {
                                    name: "crm-service_AddToCRM",
                                    color: "danger",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            },
                            {
                                name: "AddToSupport",
                                group: {
                                    name: "crm-service_AddToSupport",
                                    color: "info",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            }
                        ]
                    },
                ],
                code: [{
                    service: 'newsletter-service',
            code: `eventProc, err := cqrs.NewEventProcessorWithConfig(router, cqrs.EventProcessorConfig{
    GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
        return params.EventName, nil
    },
    SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
        return redisstream.NewSubscriber(
            redisstream.SubscriberConfig{
                Client:        subClient,
                ConsumerGroup: fmt.Sprintf("%s_%s", serviceName, params.HandlerName),
            },
            logger,
        )
    },
    Marshaler: cqrs.JSONMarshaler{
        GenerateName: cqrs.StructName,
    },
    Logger: logger,
})
if err != nil {
    panic(err)
}
err = eventProc.AddHandlers(
    cqrs.NewEventHandler("AddToPromotionsList", HandlePromotions),
    cqrs.NewEventHandler("AddToNewsList", HandleNews),
)
if err != nil {
    panic(err)
}
func HandleNews(ctx context.Context, e *common.UserSignedUp) error {
	if !e.Consents.News {
		return nil
	}

	fmt.Println("Adding user", e.UserID, "to the news list")

	return nil
}

func HandlePromotions(ctx context.Context, e *common.UserSignedUp) error {
	if !e.Consents.Marketing {
		return nil
	}

	fmt.Println("Adding user", e.UserID, "to the promotions list")

	return nil
}`,
                }, {
                    service: 'crm-service',
                    code: `eventProc, err := cqrs.NewEventProcessorWithConfig(router, cqrs.EventProcessorConfig{
    GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
        return params.EventName, nil
    },
    SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
        return redisstream.NewSubscriber(
            redisstream.SubscriberConfig{
                Client:        subClient,
                ConsumerGroup: fmt.Sprintf("%s_%s", serviceName, params.HandlerName),
            },
            logger,
        )
    },
    Marshaler: cqrs.JSONMarshaler{
        GenerateName: cqrs.StructName,
    },
    Logger: logger,
})
if err != nil {
    panic(err)
}
err = eventProc.AddHandlers(
    cqrs.NewEventHandler("AddToCRM", HandleCRM),
    cqrs.NewEventHandler("AddToSupport", HandleSupport),
)
if err != nil {
    panic(err)
}

func HandleCRM(ctx context.Context, e *common.UserSignedUp) error {
	fmt.Println("Adding user", e.UserID, "to the CRM")

	return nil
}

func HandleSupport(ctx context.Context, e *common.UserSignedUp) error {
	fmt.Println("Adding user", e.UserID, "to the support channel")

	return nil
}`
                }],
            },
            {
                number: 9,
                title: "Running two replicas of both services",
                description: `
                <p>This example is identical as the previous one except both services run two replicas.</p>
                `,
                topic: "UserSignedUp-9",
                services: [
                    {
                        name: "newsletter-service-1",
                        handlers: [
                            {
                                name: "AddToPromotionsList",
                                group: {
                                    name: "newsletter-service_AddToPromotionsList",
                                    color: "primary",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            },
                            {
                                name: "AddToNewsList",
                                group: {
                                    name: "newsletter-service_AddToNewsList",
                                    color: "warning",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            }
                        ]
                    },
                    {
                        name: "crm-service-1",
                        handlers: [
                            {
                                name: "AddToCRM",
                                group: {
                                    name: "crm-service_AddToCRM",
                                    color: "danger",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            },
                            {
                                name: "AddToSupport",
                                group: {
                                    name: "crm-service_AddToSupport",
                                    color: "info",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            }
                        ]
                    },
                    {
                        name: "newsletter-service-2",
                        handlers: [
                            {
                                name: "AddToPromotionsList",
                                group: {
                                    name: "newsletter-service_AddToPromotionsList",
                                    color: "primary",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            },
                            {
                                name: "AddToNewsList",
                                group: {
                                    name: "newsletter-service_AddToNewsList",
                                    color: "warning",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            }
                        ]
                    },
                    {
                        name: "crm-service-2",
                        handlers: [
                            {
                                name: "AddToCRM",
                                group: {
                                    name: "crm-service_AddToCRM",
                                    color: "danger",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            },
                            {
                                name: "AddToSupport",
                                group: {
                                    name: "crm-service_AddToSupport",
                                    color: "info",
                                },
                                received_message_id: "",
                                received_message_at: 0,
                            }
                        ]
                    },
                ],
                code: [{
                    service: "newsletter-service",
                    code: `eventProc, err := cqrs.NewEventProcessorWithConfig(router, cqrs.EventProcessorConfig{
    GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
        return params.EventName, nil
    },
    SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
        return redisstream.NewSubscriber(
            redisstream.SubscriberConfig{
                Client:        subClient,
                ConsumerGroup: fmt.Sprintf("%s_%s", serviceName, params.HandlerName),
            },
            logger,
        )
    },
    Marshaler: cqrs.JSONMarshaler{
        GenerateName: cqrs.StructName,
    },
    Logger: logger,
})
if err != nil {
    panic(err)
}
err = eventProc.AddHandlers(
    cqrs.NewEventHandler("AddToPromotionsList", HandlePromotions),
    cqrs.NewEventHandler("AddToNewsList", HandleNews),
)
if err != nil {
    panic(err)
}
func HandleNews(ctx context.Context, e *common.UserSignedUp) error {
	if !e.Consents.News {
		return nil
	}

	fmt.Println("Adding user", e.UserID, "to the news list")

	return nil
}

func HandlePromotions(ctx context.Context, e *common.UserSignedUp) error {
	if !e.Consents.Marketing {
		return nil
	}

	fmt.Println("Adding user", e.UserID, "to the promotions list")

	return nil
}`,
                }, {
                    service: "crm-service",
                    code: `eventProc, err := cqrs.NewEventProcessorWithConfig(router, cqrs.EventProcessorConfig{
    GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
        return params.EventName, nil
    },
    SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
        return redisstream.NewSubscriber(
            redisstream.SubscriberConfig{
                Client:        subClient,
                ConsumerGroup: fmt.Sprintf("%s_%s", serviceName, params.HandlerName),
            },
            logger,
        )
    },
    Marshaler: cqrs.JSONMarshaler{
        GenerateName: cqrs.StructName,
    },
    Logger: logger,
})
if err != nil {
    panic(err)
}
err = eventProc.AddHandlers(
    cqrs.NewEventHandler("AddToCRM", HandleCRM),
    cqrs.NewEventHandler("AddToSupport", HandleSupport),
)
if err != nil {
    panic(err)
}

func HandleCRM(ctx context.Context, e *common.UserSignedUp) error {
	fmt.Println("Adding user", e.UserID, "to the CRM")

	return nil
}

func HandleSupport(ctx context.Context, e *common.UserSignedUp) error {
	fmt.Println("Adding user", e.UserID, "to the support channel")

	return nil
}`
                }],
            },
        ]);

        React.useEffect(() => {
            const es = new EventSource("/api/messages");
            es.addEventListener('data', event => {
                console.log(event.data);
                const data = JSON.parse(event.data);

                let newChapters = [...chapters];

                const now = Date.now();

                for (let message of data) {
                    for (let i = 0; i < newChapters.length; i++) {
                        const chapter = newChapters[i];
                        if (chapter.topic === message.topic) {
                            for (let j = 0; j < chapter.services.length; j++) {
                                const service = chapter.services[j];
                                if (service.name === message.service) {
                                    for (let k = 0; k < service.handlers.length; k++) {
                                        const handler = service.handlers[k];
                                        if (handler.name === message.handler) {
                                            handler.received_message_id = message.id;
                                            handler.received_message_at = now;
                                            break;
                                        }
                                    }
                                    break;
                                }
                            }
                            break;
                        }
                    }
                }

                setChapters(newChapters);
            }, false);
        }, []);

        React.useEffect(() => {
            const interval = setInterval(() => {
                let newChapters = [...chapters];

                const now = Date.now();

                for (let i = 0; i < newChapters.length; i++) {
                    const chapter = newChapters[i];
                    for (let j = 0; j < chapter.services.length; j++) {
                        const service = chapter.services[j];
                        for (let k = 0; k < service.handlers.length; k++) {
                            const handler = service.handlers[k];
                            if (now - handler.received_message_at > 1200) {
                                handler.received_message_at = 0;
                            }
                        }
                    }
                }

                setChapters(newChapters);
            }, 100);
            return () => clearInterval(interval);
        }, []);

        return (
            <div className="container">
                <ul className="nav nav-tabs" role="tablist" style={{borderBottom: 0}}>
                    {chapters.map(chapter => (
                        <li key={chapter.number} className="nav-item mx-1" role="presentation">
                            <button
                                className={`nav-link ${chapter.number === 0 ? "active" : ""}`}
                                id={"example-tab-" + chapter.number }
                                data-bs-toggle="tab"
                                data-bs-target={"#tab-pane-" + chapter.number}
                                type="button"
                                role="tab"
                                aria-controls={"tab-pane-" + chapter.number}
                                aria-selected={chapter.number === 0 ? "true" : "false"}><span className="px-1">{chapter.number}</span></button>
                        </li>
                    ))}
                </ul>
                <div className="tab-content">
                    {chapters.map(chapter => <Chapter key={chapter.number} chapter={chapter} />)}
                </div>
            </div>
        );
    }

    function Chapter(props) {
        const [sending, setSending] = React.useState(false);

        React.useEffect(() => {
            const interval = setInterval(() => {
                if (!sending) {
                    return;
                }
                fetch('/api/messages/' + props.chapter.topic, {
                    method: 'POST',
                    headers: {
                        'Accept': 'application/json',
                    }
                });
            }, 1500);
            return () => clearInterval(interval);
        }, [sending]);

        const columns = 2;
        const rows = props.chapter.services.map((service, i) => {
            return <Service key={i} service={service} />
        }).reduce((r, element, index) => {
            if (index % columns === 0) {
                r.push([]);
            }
            r[r.length - 1].push(element);
            return r;
        }, []).map((row, i) => {
            return (
                <div key={i} className="row my-3">
                    {row}
                </div>
            );
        })

        React.useEffect(() => {
            hljs.highlightAll();
        }, []);

        return (
            <div
                className={`tab-pane fade ${props.chapter.number === 0 ? " show active" : ""}`}
                id={"tab-pane-" + props.chapter.number}
                role="tabpanel"
                aria-labelledby={"tab-" + props.chapter.number}
                tabIndex="0"
            >
                <div className="card">
                    <div className="card-header">
                        <h3>{props.chapter.number}. {props.chapter.title}</h3>
                    </div>
                    <div className="card-body">
                        <p dangerouslySetInnerHTML={{__html: props.chapter.description}}></p>

                        {props.chapter.services.length > 0 &&
                            <div className="form-check form-switch mx-3 mt-5 mb-4">
                                <input className="form-check-input" onChange={() => setSending(!sending) } type="checkbox" role="switch" id={`flexSwitchCheckDefault-${props.chapter.number}`} />
                                <label className="form-check-label" htmlFor={`flexSwitchCheckDefault-${props.chapter.number}`}>Send <span className="badge bg-dark">UserSignedUp</span> Events</label>
                                {sending && <div className="mx-3 spinner-grow spinner-grow-sm text-success" role="status"></div>}
                            </div>
                        }

                        <div className="container">
                            {rows}
                        </div>

                            <div className="accordion mt-5" id={"code" + props.chapter.number}>
                                {props.chapter.code.map(code => (
                                    <div className="accordion-item">
                                        <h2 className="accordion-header" id={"codeHeader" + props.chapter.number+code.service}>
                                            <button className="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target={"#codeCollapse" + props.chapter.number+code.service} aria-expanded="false" aria-controls={"#codeCollapse" + props.chapter.number+code.service}>
                                                Code: {code.service}
                                            </button>
                                        </h2>
                                        <div id={"codeCollapse" + props.chapter.number + code.service} className="accordion-collapse collapse" aria-labelledby={"codeHeader"+props.chapter.number+code.service} data-bs-parent={"code"+props.chapter.number+code.service}>
                                            <div className="accordion-body">
                                            <pre>
                                                <code className="language-go">
                                                    {code.code}
                                                </code>
                                            </pre>
                                            </div>
                                        </div>
                                    </div>
                                ))}
                            </div>
                    </div>
                </div>
            </div>
        );
    }

    function Service(props) {
        return (
            <div className="col-6">
                <div className="card">
                    <div className="card-header">
                        <h3>{props.service.name}</h3>
                    </div>
                    <ul className="list-group list-group-flush">
                        {props.service.handlers.map(handler => <Handler key={handler.name} handler={handler} />)}
                    </ul>
                </div>
            </div>
        );
    }

    function Handler(props) {
        return (
            <li className="list-group-item">
                <h4>{props.handler.name}</h4>
                {props.handler.group &&
                    <h4><span className={`badge text-bg-${props.handler.group.color}`}>Group: {props.handler.group.name}</span></h4>
                }
                <h1><span className={"badge " + (props.handler.received_message_at > 0 ? "text-bg-success" : "text-bg-light")}>{props.handler.received_message_id > 0 ? `Received #${props.handler.received_message_id}` : "Waiting for messages..."}</span></h1>
            </li>
        );
    }

    const container = document.getElementById('root');
    const root = ReactDOM.createRoot(container);
    root.render(<App />);

</script>
</body>
</html>
